Skip to content

fix(release): re-lock uv-managed Python packages after a version bump - #2314

Merged
BenTaylorDev merged 1 commit into
mainfrom
mark/release-relock-python
Aug 5, 2026
Merged

fix(release): re-lock uv-managed Python packages after a version bump#2314
BenTaylorDev merged 1 commit into
mainfrom
mark/release-relock-python

Conversation

@contextablemark

@contextablemark contextablemark commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2313, which repaired accumulated lockfile drift. This removes the cause.

The bug

uv.lock carries an entry for the package it locks — the one whose source is { editable = "." } — so editing pyproject.toml alone leaves that entry one version stale. prepare-release.ts did exactly that, so every release shipped a stale lock. Four consecutive aws-strands releases are each a one-line, one-file commit; ag_ui_adk drifted five releases deep before anyone noticed, because nothing reads the lock during a release.

The fix

Run uv lock after bumping a Python version, and install uv in prepare-release.yml (same pinned action and floor unit-python-sdk.yml already uses).

Packages with no uv.lock (poetry-managed, or unlocked) are skipped. A missing uv is fatal rather than skipped — silently publishing a stale lock is the failure this exists to prevent. uv's stdout is discarded so the script's JSON summary stays parseable, with stderr passed through.

One behaviour worth knowing about

uv lock may also flush latent metadata corrections unrelated to the bump. It doesn't rewrite the file unless it has a reason to, and when a bump finally gives it one, it writes the file in its current best form.

Measured: the same uv 0.9.24 binary added an exceptiongroup dependency marker on Aug 4 that it had not added on Jul 30 from an identical starting state, because uv's metadata cache had refreshed from PyPI in between. These are corrections rather than corruption — exceptiongroup genuinely needs typing-extensions only below Python 3.13.

And it settles. Simulating two releases back to back:

lock diff
release 1 (0.2.4 → 0.2.5) 2 lines — version + the latent fix
release 2 (0.2.5 → 0.2.6) 1 line — version only

A companion uv lock --check CI gate (next PR) is what keeps this clean long-term: with locks continuously current, a release bump has nothing extra to flush.

Verification

Against a scratch worktree — --dry-run writes nothing and its JSON still parses; a real bump moves pyproject.toml and uv.lock to the same version; the resulting lock passes uv lock --check.

Not covered by a test

prepare-release.ts hardcodes repoRoot to path.resolve(__dirname, "../..") and exports nothing, so the existing spawn-based tests can only exercise --dry-run, which by design never writes. Covering this path needs a main-guard refactor or a fixture repo, both larger than the fix — and a main-guard that silently mismatched would stop main() running at all, which is a worse failure than the one being fixed. Verified by running the real script against a scratch worktree instead.

@contextablemark
contextablemark requested a review from a team as a code owner August 4, 2026 23:40
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1785892160 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1785892160' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1785892160' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1785892160' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1785892160' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1785892160' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1785892160' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1785892160

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 89e29d0

@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2314

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2314

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2314

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2314

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2314

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2314

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2314

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2314

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2314

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2314

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2314

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2314

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2314

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2314

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2314

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2314

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2314

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2314

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2314

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2314

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2314

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2314

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2314

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2314

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2314

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2314

commit: f9f95f1

uv.lock carries an entry for the package it locks -- the one whose source is
{ editable = "." } -- so editing pyproject.toml alone leaves that entry one
version stale. prepare-release.ts did exactly that, so every release shipped a
stale lock: four consecutive aws-strands releases are each a one-line, one-file
commit, and ag_ui_adk drifted five releases deep before anyone noticed. #2313
repaired the accumulated drift; this stops it recurring.

Runs `uv lock`, which is what the tooling is for. Note it may also flush latent
metadata corrections unrelated to the bump: it rewrites the whole file once it
has any reason to, and what it writes reflects the package metadata in uv's cache
at that moment. Measured -- the same uv 0.9.24 binary added an exceptiongroup
dependency marker on Aug 4 that it had not added on Jul 30 from an identical
starting state, because the cache had refreshed from PyPI in between. Those are
corrections rather than corruption (exceptiongroup genuinely needs
typing-extensions only below 3.13), and the companion `uv lock --check` CI gate
(#2315) keeps them from accumulating: with locks continuously current, a release
bump has nothing extra to flush and its diff stays to the version line.

Packages with no uv.lock (poetry-managed, or unlocked) are skipped; a missing uv
is fatal rather than skipped, since silently publishing a stale lock is the
failure this exists to prevent. uv's stdout is discarded so the script's JSON
summary stays parseable, with stderr passed through for diagnostics.

Testability: repoRoot was pinned to the script's own location, so a non-dry-run
would have edited the real repo and --dry-run (which never writes) was the only
safe mode -- leaving the write path uncovered. It now honours
PREPARE_RELEASE_ROOT, so a test can point config, package files and lockfiles at
a throwaway tree. The added test builds a dependency-free fixture package (so
`uv lock` needs no network), seeds its lock with a real uv run rather than
hand-writing one, bumps it through the actual script, and asserts pyproject.toml
and uv.lock both land on the new version. Commenting out the re-lock makes it
fail with "uv.lock self-entry not re-locked", so it guards the regression rather
than the implementation.

The test skips itself when uv is absent, so test-release-scripts.yml gains the uv
install -- otherwise it would pass vacuously in CI and give false confidence.

Rejected: a main-guard refactor to make the script importable. A guard whose
condition silently mismatched would stop main() running at all -- a quiet no-op in
release tooling, worse than the bug being fixed. The env override achieves the
same testability with no effect on normal invocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BenTaylorDev
BenTaylorDev merged commit 2a57151 into main Aug 5, 2026
19 checks passed
@BenTaylorDev
BenTaylorDev deleted the mark/release-relock-python branch August 5, 2026 19:00
uesleilima pushed a commit to uesleilima/ag-ui that referenced this pull request Aug 5, 2026
Nothing validated Python lockfiles. Every --frozen-lockfile check in CI is pnpm,
which is how three released packages drifted -- ag_ui_adk five releases deep --
without a single red build (ag-ui-protocol#2313 repaired it, ag-ui-protocol#2314 removes the cause).

Adds --locked to the nine `uv sync` steps rather than nine new `uv lock --check`
steps: uv sync --locked asserts the lockfile is already in step with
pyproject.toml and exits 1 if not, so the gate rides along with a command CI
already runs. Measured on the aws-strands package -- with the lock's self-version
knocked back a patch it exits 1 with "The lockfile at `uv.lock` needs to be
updated, but `--locked` was provided", and exits 0 once restored.

Safe to merge as-is: verified that all eight uv-managed packages this workflow
covers currently pass `uv lock --check` against main, so no job goes red on
landing. Note the check is resolution-level, not byte-level -- it tolerates a lock
whose metadata representation is merely older than what today's uv would emit,
and fails only when the lock genuinely disagrees with pyproject. That is the right
sensitivity here: the version drift this exists to catch trips it, and a uv
upgrade alone does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mme added a commit that referenced this pull request Aug 6, 2026
All 18 astral-sh/setup-uv invocations resolved to a floating '>=0.8.0' or to no
version at all; each now takes the exact pin, and every venv cache key carries both
versions so an environment built by one toolchain is never restored into a job
expecting another.

Adds the 'uv lock --check' gate #2314 named as its companion: it runs over every
first-party lock, and separately asserts no first-party package is missing one,
since a package with no lockfile is invisible to a find-based check. Each Python job
that installs dependencies ends with an assertion that it left no committed lockfile
modified — by outcome (git status), so nothing has to infer which commands ran.

Also completes #2314's re-lock. prepare-release.ts re-locks each package it bumps,
but the bump result names only the manifest, so nothing staged the rewritten
uv.lock and the commit shipped the manifest alone — recreating the drift #2313 had
to repair by hand, and now turning release PRs red against the gate above. The
commit step stages the lockfile beside each manifest, and prepare-release.yml pins
the uv whose output lands in those committed locks.
mme added a commit that referenced this pull request Aug 6, 2026
All 18 astral-sh/setup-uv invocations resolved to a floating '>=0.8.0' or to no
version at all; each now takes the exact pin, and every venv cache key carries both
versions so an environment built by one toolchain is never restored into a job
expecting another. The pin matters most in prepare-release.yml, where #2314 made it
the uv whose output lands in a committed lockfile.

Adds the 'uv lock --check' gate #2314 named as its companion. It runs over every
first-party lock, which widens coverage past the nine packages that have a test
lane — agent-spec and claude-agent-sdk ship locks with no lane at all, which is how
the drift #2313 repaired went unnoticed. It also asserts no first-party package is
missing a lock, since a package with none is invisible to a find-based check.

Each Python job that installs dependencies ends with an assertion that it left no
committed lockfile modified, measured by outcome (git status) so nothing has to
infer which commands ran.
mme added a commit that referenced this pull request Aug 6, 2026
All 18 astral-sh/setup-uv invocations resolved to a floating '>=0.8.0' or named no
version at all, so any job could silently get a uv no green run had used. Each now
takes the exact pin from .github/python-toolchain.env.

Venv cache keys gain the same two versions, so an environment built by one toolchain
is never restored into a job expecting another — the failure mode that hides drift
rather than surfacing it.

The pin matters most in prepare-release.yml: since #2314 that is the uv whose output
lands in a committed lockfile. Also declares the depot self-hosted runner label so
the widened actionlint scope resolves it.
mme added a commit that referenced this pull request Aug 6, 2026
Adds the 'uv lock --check' gate #2314 named as its companion. It runs over every
first-party lock, which widens coverage past the nine packages that have a test
lane: agent-spec and claude-agent-sdk ship committed locks with no lane at all,
which is how the drift #2313 repaired went unnoticed for several releases. It also
asserts no first-party package is missing a lock, since a package with none is
invisible to a find-based check and passes silently.

Each Python job that installs dependencies now ends with an assertion that it left
no committed lockfile modified. That is measured by outcome — git status over every
tracked lockfile — so nothing has to infer which commands a job ran. It runs with
if: always(), so a job that rewrote a lock and then failed still reports it.
mme added a commit that referenced this pull request Aug 6, 2026
All 18 astral-sh/setup-uv invocations resolved to a floating '>=0.8.0' or named no
version at all, so any job could silently get a uv no green run had used. Each now
takes the exact pin from .github/python-toolchain.env.

Venv cache keys gain the same two versions, so an environment built by one toolchain
is never restored into a job expecting another — the failure mode that hides drift
rather than surfacing it.

The pin matters most in prepare-release.yml: since #2314 that is the uv whose output
lands in a committed lockfile. Also declares the depot self-hosted runner label so
the widened actionlint scope resolves it.
mme added a commit that referenced this pull request Aug 6, 2026
Adds the 'uv lock --check' gate #2314 named as its companion. It runs over every
first-party lock, which widens coverage past the nine packages that have a test
lane: agent-spec and claude-agent-sdk ship committed locks with no lane at all,
which is how the drift #2313 repaired went unnoticed for several releases. It also
asserts no first-party package is missing a lock, since a package with none is
invisible to a find-based check and passes silently.

Each Python job that installs dependencies now ends with an assertion that it left
no committed lockfile modified. That is measured by outcome — git status over every
tracked lockfile — so nothing has to infer which commands a job ran. It runs with
if: always(), so a job that rewrote a lock and then failed still reports it.
piscoTech added a commit to ciolo/ag-ui that referenced this pull request Aug 6, 2026
* docs(tools): document ToolMessage.error in tool result examples

`ToolMessage.error` is documented on the messages concept page and in the
JS, Python, and Ruby SDK references, but two pages omit it -- both pages an
integration author is likely to work from.

- concepts/tools.mdx: the Tool Results example showed only `content` +
  `toolCallId`. Adds a failure example alongside the existing success one,
  reusing the page's confirmAction / tool-123 scenario.
- sdk/kotlin/core/types.mdx: the ToolMessage declaration omitted `error`,
  which the Kotlin SDK does have. Also corrects the name / toolCallId order
  to match the source.

concepts/events.mdx was checked and left alone: ToolCallResultEvent has no
`error` field in either SDK, so its property table is accurate.

Refs ag-ui-protocol#2306, ag-ui-protocol#2226

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(python): fail the build on a stale uv.lock

Nothing validated Python lockfiles. Every --frozen-lockfile check in CI is pnpm,
which is how three released packages drifted -- ag_ui_adk five releases deep --
without a single red build (ag-ui-protocol#2313 repaired it, ag-ui-protocol#2314 removes the cause).

Adds --locked to the nine `uv sync` steps rather than nine new `uv lock --check`
steps: uv sync --locked asserts the lockfile is already in step with
pyproject.toml and exits 1 if not, so the gate rides along with a command CI
already runs. Measured on the aws-strands package -- with the lock's self-version
knocked back a patch it exits 1 with "The lockfile at `uv.lock` needs to be
updated, but `--locked` was provided", and exits 0 once restored.

Safe to merge as-is: verified that all eight uv-managed packages this workflow
covers currently pass `uv lock --check` against main, so no job goes red on
landing. Note the check is resolution-level, not byte-level -- it tolerates a lock
whose metadata representation is merely older than what today's uv would emit,
and fails only when the lock genuinely disagrees with pyproject. That is the right
sensitivity here: the version drift this exists to catch trips it, and a uv
upgrade alone does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(release): re-lock uv-managed Python packages after a version bump

uv.lock carries an entry for the package it locks -- the one whose source is
{ editable = "." } -- so editing pyproject.toml alone leaves that entry one
version stale. prepare-release.ts did exactly that, so every release shipped a
stale lock: four consecutive aws-strands releases are each a one-line, one-file
commit, and ag_ui_adk drifted five releases deep before anyone noticed. ag-ui-protocol#2313
repaired the accumulated drift; this stops it recurring.

Runs `uv lock`, which is what the tooling is for. Note it may also flush latent
metadata corrections unrelated to the bump: it rewrites the whole file once it
has any reason to, and what it writes reflects the package metadata in uv's cache
at that moment. Measured -- the same uv 0.9.24 binary added an exceptiongroup
dependency marker on Aug 4 that it had not added on Jul 30 from an identical
starting state, because the cache had refreshed from PyPI in between. Those are
corrections rather than corruption (exceptiongroup genuinely needs
typing-extensions only below 3.13), and the companion `uv lock --check` CI gate
(ag-ui-protocol#2315) keeps them from accumulating: with locks continuously current, a release
bump has nothing extra to flush and its diff stays to the version line.

Packages with no uv.lock (poetry-managed, or unlocked) are skipped; a missing uv
is fatal rather than skipped, since silently publishing a stale lock is the
failure this exists to prevent. uv's stdout is discarded so the script's JSON
summary stays parseable, with stderr passed through for diagnostics.

Testability: repoRoot was pinned to the script's own location, so a non-dry-run
would have edited the real repo and --dry-run (which never writes) was the only
safe mode -- leaving the write path uncovered. It now honours
PREPARE_RELEASE_ROOT, so a test can point config, package files and lockfiles at
a throwaway tree. The added test builds a dependency-free fixture package (so
`uv lock` needs no network), seeds its lock with a real uv run rather than
hand-writing one, bumps it through the actual script, and asserts pyproject.toml
and uv.lock both land on the new version. Commenting out the re-lock makes it
fail with "uv.lock self-entry not re-locked", so it guards the regression rather
than the implementation.

The test skips itself when uv is absent, so test-release-scripts.yml gains the uv
install -- otherwise it would pass vacuously in CI and give false confidence.

Rejected: a main-guard refactor to make the script importable. A guard whose
condition silently mismatched would stop main() running at all -- a quiet no-op in
release tooling, worse than the bug being fixed. The env override achieves the
same testability with no effect on normal invocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): update github/codeql-action action to v4.37.6

* fix(repo): reconcile Git LFS policy with repository contents

`git lfs fsck --pointers HEAD` reported 34 files committed as ordinary Git
blobs on paths that .gitattributes declares LFS-governed, and every clone
printed a 34-line "should have been pointers, but weren't" notice.

PNI-185 proposed converting those 34 files to pointers. Investigating each
one showed the policy, not the contents, is what is wrong:

  * docs/** (9 files, 7.4 MB) is served straight from a repository checkout
    by Mintlify, which does not hydrate Git LFS -- an open, unanswered
    feature request since March 2026. Converting these would serve 130-byte
    pointer text in place of every docs image and the landing-page video.

  * apps/dojo/public|src (14 files, 3.1 MB) is served the same way by Render.

  * The Kotlin example's launcher icons (10 files) are Android Studio
    template output at 1-8 KB each -- smaller than the overhead LFS adds.

  * config_test.dill (10.3 MB, 48% of the total) is a Dart kernel snapshot:
    compiler output committed by accident in ag-ui-protocol#316, referenced by nothing.
    It escaped .gitignore only because the compiler wrote it beside its
    source rather than into .dart_tool/ or build/.

Converting would also not have shrunk the repository. A forward-only change
leaves the original blobs in history, so full clones keep carrying all
21.3 MB and would additionally fetch the LFS objects on every hydrating
clone -- against ~95k clones/month, purely additive bandwidth.

So: exempt the four path groups, delete the build artifact, and drop the now
dead *.dill LFS rule. No file is converted and no LFS object is uploaded.

Adds .github/scripts/check-lfs-pointers.sh, which derives the governed set
from `git check-attr` rather than any filename list, and fails in both
directions -- a plain blob on a governed path, and a stranded pointer left
behind by a future exemption. Verified: 34 violations on the previous main,
zero here; fails on a plain blob added to a governed path and passes once
the same file is re-added through LFS.

Refs PNI-185

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): name the LFS pointer check job

Clears zizmor/anonymous-definition (note severity) on the job added by the
previous commit.

Only the new job is named. The two pre-existing jobs in this file trip the
same rule, but renaming them would change their reported check context, and
`main` is protected with required checks that are not readable without admin
access -- if either is required by name, the rename would leave the context
permanently pending.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: document chat platforms as an AG-UI client surface

The Clients listings covered web, terminal, and React Native, which left
the impression that AG-UI targets web applications. Add chat platforms
(Slack, Microsoft Teams) alongside the existing entries, and state that a
client need not be a web app because the protocol describes an event
stream rather than a rendering target.

The added text separates the layers: a chat-platform client is something
anyone can build against AG-UI, the Channels SDK is one implementation,
OpenTag is a readable application built on it, and CopilotKit
Intelligence is named as a hosted service outside the protocol.

No protocol, specification, or event-set changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make TOOL_CALL_START idempotent

The TOOL_CALL_START handler appended to the parent assistant message's
`toolCalls` with no check for an existing entry, so any re-application of
a start event corrupted message state. The same start can reach the
reducer twice — a tool call already carried in `agent.messages` from an
earlier run and then replayed by the backend (the HITL path does this
when the run re-syncs after `respond()`), or one stream re-delivered over
two transports.

The duplicate's `arguments` stay empty, because TOOL_CALL_ARGS deltas
resolve to the first match. That malformed assistant message — the same
tool call twice, one copy with no arguments — is what travels back to the
provider on the next turn.

Resolve any existing entry for the id and update it in place instead of
pushing a second one, leaving `arguments` untouched (a start event carries
none, so the copy already in state holds the only streamed args). A start
that reuses an id under a different name warns and updates the name.

The check runs before resolveOrCreateAssistantMessage, which also closes a
second latent defect on the same path: a replay whose parentMessageId is
no longer in state would otherwise create a stray empty assistant message
to hang the duplicate off. A replay is now a true no-op — no duplicate, no
stray message, and no state emission at all.

Note that TOOL_CALL_ARGS remains non-idempotent by nature (deltas append).
The evidence in CPK-7770 shows only the start event was replayed, so this
covers the observed defect; whole-stream re-delivery would surface next as
doubled arguments, which cannot be deduped without sequence numbers.

Refs CPK-7772, follow-up to CPK-7770 / CopilotKit#6407

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): update swatinem/rust-cache action to v2.9.2

---------

Co-authored-by: Mark Fogle <mark@copilotkit.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Markus Ecker <markus.ecker@gmail.com>
Co-authored-by: Ben Taylor <ben@copilotkit.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants